---
title: "Entitlement Model"
type: concept
created: 2026-04-18
updated: 2026-04-18
sources: ["Sig Dug direct instruction 2026-04-18"]
tags: [data-model, entitlement, billing, teacher-license, v1]
---

# Entitlement Model

> **⚠️ v1 Billing Model — Confirmed by Sig Dug, 2026-04-18**
> Billing unit = **1 license per teacher**. Teacher pays once, all their classes are covered. Each class is independently capped at 33 active students. No per-class billing. No shared seat pools across classes.

## How It Works

A **teacher license** is the atomic billing unit in v1:

- 1 teacher = 1 license = **all their classes covered**
- Each class is independently capped at **33 active memberships**
- A teacher with 5 classes pays once — not 5 times
- Entitlement is tied to the **teacher**, not to any individual class
- Students inherit access from the teacher's entitlement tier for sessions in that teacher's class

```
Teacher pays 1 license
       ↓
teacher_licenses.teacher_id = this teacher
       ↓
All classes owned by this teacher are covered
       ↓
Each class independently enforces 33-student cap via class_memberships count
       ↓
Student entitlement = teacher's tier for that class's teacher
```

**Multi-class example:**
- Teacher A (math + history + science, `teacher_paid`) — one license, three classes
- Each class capped at 33 students independently
- Total potential: 99 students across 3 classes — still one license

**Cross-teacher example:**
- Teacher A (math, `teacher_paid`) and Teacher B (history, `trial`) both have Sofia
- Sofia's math sessions: `teacher_paid` entitlement applies (Teacher A's tier)
- Sofia's history sessions: `trial` entitlement applies (Teacher B's tier)
- Teacher A and Teacher B's capacity are **completely independent per class**

## Subscription Options

| Option | Billing Cycle | Scope |
|---|---|---|
| `monthly` | Per teacher / month | All teacher's classes, 33 students per class |
| `yearly` | Per teacher / year | All teacher's classes, 33 students per class (discounted) |

## v1 Tiers

| Tier | Precedence | Description | Access |
|---|---|---|---|
| `enterprise` | 1 (highest) | External entitlement — school/government license, ingested from external system | Full features — overrides all other tiers |
| `teacher_paid` | 2 | Active teacher license (monthly or yearly) | Full features for all students in teacher's classes |
| `trial` | 3 | 14-day free trial on registration | Full features |
| `gifted` | 4 | Manually granted by platform_admin | Full features (no payment) |
| `free` | 5 (lowest) | Default — no active license | 50 books, no adaptive leveling, no Learner Bot |

**Precedence rule:** `enterprise > teacher_paid > trial > gifted > free`.

> **Not in v1:** `parent_paid`, `school_paid`, `class_paid` — do not build, do not reference.

## Class Capacity Enforcement — Non-Negotiable

**⚠️ Non-Negotiable (Sig, 2026-04-18):** The system must block adding membership #34 to any single class. This is a **per-class** hard cap only — it is NOT a cap across all of the teacher's classes.

```
Teacher A — teacher_paid license

  Class: Math (33 cap)
  ├── 30 active students
  └── 3 slots remaining

  Class: History (33 cap)
  ├── 28 active students
  └── 5 slots remaining

  Class: Science (33 cap)
  ├── 15 active students
  └── 18 slots remaining

Total: 73 students across 3 classes. One license. All valid.
```

### What Counts as an Active Membership

A membership is **active** if `class_memberships.archived_at IS NULL`.

```sql
-- Seat count check (per class)
SELECT COUNT(*) FROM class_memberships
WHERE class_id = ? AND archived_at IS NULL
```

If this count ≥ 33 → reject add with `422 CLASS_FULL`.

### Atomicity

Seat check and insert must be **atomic** to prevent race conditions:

```sql
BEGIN;
SELECT COUNT(*) FROM class_memberships
  WHERE class_id = ? AND archived_at IS NULL
  FOR UPDATE;
-- if count >= 33: ROLLBACK → return 422
INSERT INTO class_memberships (student_id, class_id, enrolled_at) VALUES (?, ?, NOW());
COMMIT;
```

### Archiving

Archiving a membership immediately frees the slot — no delay, no batch job. `UPDATE class_memberships SET archived_at = NOW() WHERE ...`.

## checkEntitlement(teacherId)

Entitlement is resolved **per teacher**.

```typescript
async function checkEntitlement(teacherId: string): Promise<EntitlementResult> {
  // 1. Check cache (key: teacher_id, TTL: 60s / 5min enterprise)
  const cached = await entitlementCache.get(teacherId);
  if (cached) return cached;

  // 2. Check external entitlements (teacher or school scope)
  const external = await db.query(`
    SELECT tier FROM external_entitlements
    WHERE (teacher_id = ? OR school_id = (SELECT school_id FROM teachers WHERE id = ?))
    AND (expires_at IS NULL OR expires_at > NOW())
    ORDER BY tier DESC LIMIT 1
  `, [teacherId, teacherId]);
  if (external.length > 0) return cache({ tier: 'enterprise', source: 'external' });

  // 3. Check teacher_licenses
  const license = await db.query(
    `SELECT tier, state, grace_ends_at FROM teacher_licenses WHERE teacher_id = ?`,
    [teacherId]
  );
  if (!license.length) return cache({ tier: 'free', source: 'default' });

  const { tier, state, grace_ends_at } = license[0];
  if (state === 'active' || state === 'trialing') return cache({ tier, source: 'license' });
  if (state === 'past_due' && grace_ends_at > new Date()) return cache({ tier, source: 'grace' });

  return cache({ tier: 'free', source: 'expired' });
}
```

### Fail-Open (Confirmed Production Behavior)

On Account Center timeout:
1. Serve last-known cached tier (5 min for enterprise, 60s for standard)
2. After cache expires → block feature gates (quizzes, bot, adaptive), **never block child reading**
3. Every fallback writes `audit_log` entry + `logger.warn`
4. Enterprise accounts emit `enterprise_entitlement_fallback` alert

This is confirmed production behavior — not a temporary workaround.

## License Lifecycle

```
[teacher registers]
       ↓
  trialing (14 days, all features)
       ↓
  [trial ends] → free  OR  [teacher pays] → active
       ↓
  active → past_due (payment failed)
                 ↓
           grace_ends_at (7 days)
                 ↓
           expired → free tier (data retained, nothing deleted)
```

On `cancelled`: access continues until `period_end`, then `expired`.

## Key Tables

**`teacher_licenses`** — one row per teacher

```sql
CREATE TABLE teacher_licenses (
  id               VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
  teacher_id       VARCHAR(36) NOT NULL UNIQUE,  -- 1 license per teacher
  tier             ENUM('free','trial','teacher_paid','enterprise','gifted') NOT NULL DEFAULT 'free',
  billing_cycle    ENUM('monthly','yearly') NULL,
  state            ENUM('trialing','active','past_due','expired','cancelled') NOT NULL DEFAULT 'trialing',
  stripe_sub_id    VARCHAR(100) NULL,
  stripe_coupon_id VARCHAR(100) NULL,
  period_start     DATETIME NULL,
  period_end       DATETIME NULL,
  grace_ends_at    DATETIME NULL,
  created_at       DATETIME NOT NULL DEFAULT NOW()
);
```

**`class_memberships`** — capacity enforced here

```sql
CREATE TABLE class_memberships (
  student_id   VARCHAR(36) NOT NULL,
  class_id     VARCHAR(36) NOT NULL,
  enrolled_at  DATETIME NOT NULL DEFAULT NOW(),
  archived_at  DATETIME NULL,
  PRIMARY KEY (student_id, class_id)
);
```

**`external_entitlements`** — school/government licenses

```sql
CREATE TABLE external_entitlements (
  id           VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
  teacher_id   VARCHAR(36) NULL,
  school_id    VARCHAR(36) NULL,
  source       VARCHAR(100) NOT NULL,
  tier         ENUM('enterprise') NOT NULL DEFAULT 'enterprise',
  granted_at   DATETIME NOT NULL DEFAULT NOW(),
  expires_at   DATETIME NULL,
  max_students INT NULL,
  external_ref VARCHAR(255) NULL,
  metadata     JSON NULL
);
```

## Edge Cases

| Scenario | Result |
|---|---|
| Add student as 34th member of class | 422 `CLASS_FULL` — hard server block |
| Teacher license expires | All teacher's classes → `free`, teacher sees upgrade prompt |
| Past_due grace (7 days) | Full access maintained |
| Grace ends | `free`: bot stops, quizzes locked, reports locked. Child reading continues. |
| Membership archived | Slot freed immediately |
| Class archived | Data retained, all memberships access-locked |
| Teacher has 5 classes × 33 students | 165 students total — valid, one license |
| Student in 2 classes same teacher | 1 slot consumed in each class |
| Student in 2 classes different teachers | Each teacher's cap is independent |
| Enterprise + teacher_paid overlap | `enterprise` wins |

## Billing vs Analytics Separation

- **Entitlement:** `teacher_id` → `teacher_licenses` (teacher scope)
- **Capacity:** `class_id` → `class_memberships` count (class scope)
- **Analytics:** `learner_id` → reading data (student scope)

Never conflate these. Child reading is never blocked by entitlement state.
